windows 驱动基础

Windows 基础
内核对象
内核对象(Kernel Object),是 Windows 内核里对各种内核资源进行统一管理、统一命名、统一访问控制、统一生命周期控制的一种抽象机制。
设计思想
在没有内核对象之前,Windows 内核里存在着大量不同类型的资源(如设备,文件,进程线程等),这些资源每种结构体都不一样,但是每种都要支持用户态访问(有名字,有权限控制,有引用计数,有安全策略)。
因此 Windows 采用了一种类似 Linux 的 “万物皆文件” 的设计思想,即将每个需要统一管理的内核资源都被包装成一个 “内核对象”,交由 Object Manager(对象管理器) 组件管理。这样可以统一的解决下面几个问题:
典型问题 | Object Manager 提供的统一解法 |
---|---|
内核里有驱动、设备、进程、互斥量、事件、共享内存等几十种资源,各自要命名、要 ACL、要引用计数、要调试符号。 | 定义 OBJECT_HEADER + OBJECT_BODY 模型;创建/打开/引用/关闭/删除流程全部交给 Object Manager(Ob)。 |
资源的生命周期复杂:谁来保证用完才释放? | PointerCount (内核指针引用) + HandleCount (用户/内核句柄数量)双计数模型,当二者皆为 0 时由 Ob 自动回收。 |
用户进程需要安全地访问部分内核资源 | 把 SECURITY_DESCRIPTOR 嵌进对象;用户通过系统调用走 SeAccessCheck。 |
调试/监控工具需要统一查看 | 所有命名对象都挂进 对象目录树(Directory Object);Windbg / ETW / AV 可以枚举。 |
内核对象结构
每个内核对象在被 Object Manager 在分配时,自动套上了一个统一的“对象头部”+(可能存在的附加信息头)+ 对象体数据。
1 | |---------------------------| |
其中 OBJECT_HEADER
在内核对象中一定存在,该结构体在不同版本的 Windows 中会发生变化,下面是一些常见字段:
字段 | 作用 |
---|---|
PointerCount (LONG) |
内核所有“裸指针”引用计数 |
HandleCount (LONG) |
所有进程句柄数量 |
Type (POBJECT_TYPE) |
指向 DRIVER_OBJECT_TYPE / DEVICE_OBJECT_TYPE … |
Flags |
OB_FLAG_PERMANENT / EXCLUSIVE / KERNEL_MODE 等 |
InfoMask |
标记是否有 Name/Handle/Quota 这三种可选头 |
注意
Win11 以后 PatchGuard 会随机调整可选头偏移,驱动代码必须使用官方宏(OBJECT_HEADER_NAME_INFO_OFFSET
等)而非写死偏移。
Object Manager 命名空间
Object Manager 命名空间 是 Windows 内核中统一管理一切内核对象的“对象目录树”。
- 它的本质是一个内核内存中的目录树结构;
- 每个可以被命名的内核对象都被挂载在这棵树上;
- Object Manager 负责解析路径、查找对象、引用计数、权限控制等一切逻辑。
整个 Object Manager 命名空间以 \
为根目录,形成一棵类似文件系统的目录树。
1 | \ (根目录,DirectoryObject) |
其中每一类目录的具体作用如下:
目录名 | 作用 | 常见对象类型 |
---|---|---|
\Driver |
存放所有已注册的内核驱动对象 | DriverObject |
\Device |
存放所有设备对象,供 I/O 管理器使用 | DeviceObject |
\?? |
符号链接目录:Win32 路径与内核对象桥接 | SymbolicLinkObject |
\BaseNamedObjects |
全局同步对象命名区(Session 0 共享) | Event / Mutex / Semaphore |
\Sessions\N\BaseNamedObjects |
多用户会话隔离命名空间 | 各自的同步对象 |
\ObjectTypes |
存放系统内置的对象类型定义表 | ObjectTypeObject |
其中 \??
目录(全名为 DosDevices Directory
)是 Object Manager 里专门用来桥接 Win32 路径系统 ↔ 内核命名空间 的目录。它里面挂载的都是 符号链接对象(SymbolicLinkObject),用于:
- 盘符映射 (
C:
→\Device\HarddiskVolumeX
) - 传统设备名 (
COM1
→\Device\Serial0
) - 自定义设备别名(通过
IoCreateSymbolicLink()
创建)
提示
所以你看到的 \\.\COM1
,Win32 实际内部转为 \??\COM1
,由 Object Manager 查找对应符号链接完成跳转。
只要某路径存在于 \??\
下,并且链接指向有效内核对象,用户态理论上就能访问。(不考虑权限问题)
如果路径不经过 \??\
(例如裸的 \Device\xxx
、\Driver\xxx
、\BaseNamedObjects\xxx
),则 3环无法直接访问。
常用 API
ObReferenceObjectByName
函数可以用路径字符串找到任何已存在内核对象,并返回其内核对象指针。1
2
3
4
5
6
7
8
9
10NTSTATUS ObReferenceObjectByName(
IN PUNICODE_STRING ObjectName, // [输入] 要查找的对象完整路径名 (例如: "\\Driver\\MyDriver")
IN ULONG Attributes, // [输入] 属性标志,常用 OBJ_CASE_INSENSITIVE (忽略大小写匹配)
IN PACCESS_STATE AccessState OPTIONAL, // [输入] 安全访问状态,普通内核使用时传 NULL
IN ACCESS_MASK DesiredAccess, // [输入] 请求的访问权限,一般填 0 表示默认即可
IN POBJECT_TYPE ObjectType, // [输入] 对象类型指针,例如 *IoDriverObjectType、*IoDeviceObjectType 等
IN KPROCESSOR_MODE AccessMode, // [输入] 访问模式,一般传 KernelMode
IN PVOID ParseContext OPTIONAL, // [输入] 解析上下文 (高阶场景使用),通常传 NULL
OUT PVOID *Object // [输出] 成功时返回获取到的对象指针 (注意:需 ObDereferenceObject 释放引用计数)
);
路径
四类常见路径
Windows 系统有四种路径:
Win32 路径(DOS 路径) :用户程序使用的路径,例如:
1
C:\Windows\System32\drivers\Test.sys
仅存在于 Win32 API 层;内核本身不识别盘符。
首次进入内核时经
RtlDosPathNameToNtPathName
转成\??\C:\...
。提示
\??
是 Object Manager 命名空间下的一个目录,里面存放大量符号链接(充当快捷方式),这些符号链接用于将盘符、传统设备名等映射到内核对象的真实路径。当 Win32 子系统将用户空间的 DOS 路径传入内核时,会先将其转换为以\??
为前缀的 NT 路径,由内核在\??
目录中解析出对应的真实内核对象路径。
NT 文件路径 :I/O 管理器与文件系统驱动的直接输入,例如:
1
\Device\HarddiskVolume1\Windows\System32\drivers\Test.sys
盘符被解析为真正卷设备对象。
ZwCreateFile/ZwOpenFile
等内核 API 需传此类路径
Win32 设备路径
\\.\
用户态的路径会被Win32默认认为要访问的是”文件系统”里的文件。Win32 会按照文件系统路径解析的逻辑进行处理:
解析
C:
盘符;(由于设备路径缺少盘符,一般会在这一步报错❌)找到其对应物理卷;
然后交给文件系统驱动处理。
然而,像 COM1 串口、物理硬盘 (
PhysicalDrive0
)、USB 端口、命名管道、内核设备对象这些根本不属于文件系统。文件系统找不到这些对象,它们被挂在\Device\xxx
下(内核对象管理器里)。因此如果用户程序也希望用标准的CreateFile()
访问设备对象则 Win32 会试图当做文件路径来走,肯定会失败。于是微软在 Win32 设计了一个特殊标记机制:只要路径以\\.\
开头,Win32 不参与文件系统逻辑,而是把后面的内容原样放进\??
命名空间,留给内核对象管理器自己去解析。例如:1
2\\.\COM1 → \??\COM1 → \Device\Serial0
\\.\PhysicalDrive0 → \??\PhysicalDrive0 → \Device\Harddisk0\DR0Object Manager 路径 :所有内核对象的正式地址。样的路径仅对对象管理函数有效,如
ObReferenceObjectByName
、IoCreateDevice
、IoCreateSymbolicLink
等 API 可以直接使用;对文件 API 无意义。
典型解析链路
文件示例:
1 | CreateFile("C:\\Windows\\System32\\drivers\\Test.sys") |
设备示例:
1 | CreateFile("\\\\.\\COM1") |
驱动基本概念
驱动程序(Driver)是运行在操作系统内核或用户模式中的软件组件,负责在操作系统与硬件设备之间“翻译”命令与数据。
驱动框架
微软为简化驱动开发,提供了三种主要框架:
框架名 | 全称 | 运行模式 | 推荐用途 |
---|---|---|---|
WDM | Windows Driver Model | 内核模式 | 底层控制、兼容性极强,但复杂 |
KMDF | Kernel-Mode Driver Framework | 内核模式 | 封装了 WDM 的常见任务(如 PnP、电源管理、同步、I/O 队列等),推荐用于大多数设备驱动开发 |
UMDF | User-Mode Driver Framework | 用户模式 | WDF 框架的另一部分,适用于开发运行在用户模式的驱动,推荐用于外围、低风险设备驱动 |
考虑到兼容性,我们通常采用 WDM 框架开发驱动。
驱动服务名
驱动的服务名(Service Name)是系统用来识别和管理驱动程序的逻辑标识符,它是注册表 HKLM\SYSTEM\CurrentControlSet\Services
下的子项名称,也是驱动服务控制、注册、加载、配置等操作的核心索引键。
例如如果我们加载一个名称为 Services.sys
的驱动,则会在注册表中对应创建一个 HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services
子项。
CurrentControlSet
→ 实际指向ControlSet001
或ControlSet002
,系统启动时动态决定Services
→ 包含所有服务与驱动程序的定义项
在该子项中通常有如下键值对:
键名 | 类型 | 示例值 | 说明 |
---|---|---|---|
ImagePath | REG_EXPAND_SZ |
\??\C:\Path\to\MyDriver.sys |
驱动文件路径,通常位于 %SystemRoot%\System32\drivers\ |
Type | REG_DWORD |
1 , 2 |
指定服务/驱动类型(详见下文) |
Start | REG_DWORD |
0 , 1 , 3 |
启动类型(详见下文) |
Group | REG_SZ |
Base , Boot Bus Extender |
指定驱动分组,影响加载顺序 |
ErrorControl | REG_DWORD |
1 |
启动失败时的处理方式 |
DisplayName | REG_SZ |
My Sample Driver |
控制面板中显示的服务名称(可选) |
Description | REG_SZ |
Test WDM Driver |
人类可读描述信息(可选) |
Tag | REG_DWORD |
分组内排序标识(较少使用) | |
Parameters | REG_KEY |
子键 | 自定义参数保存区,驱动可读取用于配置 |
\??\
是 Windows 内核对象管理器(Object Manager)中的一个符号链接目录,代表当前会话的 DosDevices(用户态设备路径)目录。
\??\
通常映射到\GLOBAL??
,用于解析用户模式中的路径名,如:
\??\C:\Windows\System32
→ 实际解析为\Device\HarddiskVolumeX\Windows\System32
\??\COM1
→ 实际是\Device\Serial0
其中 Start
类型表示驱动何时启动,不同的值有如下含义:
值 | 含义 | 示例用途 |
---|---|---|
0 |
BOOT_START :引导时加载(Boot Loader 加载) | 如磁盘控制器驱动 |
1 |
SYSTEM_START :内核初始化阶段加载 | 大多数普通内核驱动 |
2 |
AUTO_START :Service Control Manager 启动时加载 | 系统服务,非 PnP 驱动 |
3 |
DEMAND_START :按需手动启动 | 测试驱动、虚拟设备 |
4 |
DISABLED :禁用服务 | 禁用驱动或服务启动 |
Type
类型表示服务/驱动的类别,不同的值有如下含义:
值 | 含义 | 示例 |
---|---|---|
1 |
内核驱动(SERVICE_KERNEL_DRIVER ) |
.sys 驱动,运行在 Ring 0 |
2 |
文件系统驱动(SERVICE_FILE_SYSTEM_DRIVER ) |
NTFS、FAT 等 |
10 |
Win32 服务(用户模式,SERVICE_WIN32_OWN_PROCESS ) |
普通服务程序 |
驱动加载
Windows 支持两种主要的内核驱动加载方式:
- 高层推荐方式:通过 SCM(服务控制管理器)
- 底层直接方式:通过 ZwLoadDriver(系统调用)
这两种方式都依赖于 驱动服务名对应的注册表项。
- SCM 方式加载不是由本进程完成的(实际由系统进程,如
services.exe
),因此在 0 环不容易通过行为定位到进程。 - ZwLoadDriver 方式加载过程可控,不容易被系统策略拦截。实际情况下
ZwLoadDriver
方式加载签名异常驱动的成功率高一些。
SCM 加载(服务控制管理器)
SCM 加载是 Windows 推荐的标准驱动加载方式。驱动作为一种特殊的“服务”被注册(类型为 SERVICE_KERNEL_DRIVER
),然后由 服务控制管理器(SCM) 调用底层内核服务 NtLoadDriver
加载 .sys
驱动文件。
原理流程
Windows 把驱动程序视为一种特殊的服务,类型为 SERVICE_KERNEL_DRIVER
。通过一套标准 API,开发者可以注册、启动、停止和卸载驱动。每个 API 都与注册表和内核交互紧密关联。
OpenSCManager
:连接到本地或远程计算机上的 SCM(服务控制管理器),并获取一个 SCM 句柄,用于后续服务管理操作。1
2
3
4
5SC_HANDLE OpenSCManager(
LPCSTR lpMachineName, // 计算机名,NULL 表示本地
LPCSTR lpDatabaseName, // 数据库名,通常为 NULL 或 "ServicesActive"
DWORD dwDesiredAccess // 访问权限(如 SC_MANAGER_ALL_ACCESS)
);lpMachineName
:目标计算机名称。为NULL
时表示本地计算机。lpDatabaseName
:服务数据库名称,通常为NULL
或默认值"ServicesActive"
。dwDesiredAccess
:请求的访问权限。建议使用SC_MANAGER_ALL_ACCESS
以便执行创建、删除等所有操作。
CreateService
:在 SCM 中注册一个新服务(或驱动),生成注册表项并配置驱动加载参数。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15SC_HANDLE CreateService(
SC_HANDLE hSCManager, // 打开的服务控制管理器句柄
LPCSTR lpServiceName, // 服务逻辑名称(注册表键名)
LPCSTR lpDisplayName, // 显示名称(服务管理器界面显示)
DWORD dwDesiredAccess, // 返回句柄的访问权限
DWORD dwServiceType, // 服务类型(如内核驱动)
DWORD dwStartType, // 启动类型(如手动、系统、引导)
DWORD dwErrorControl, // 启动失败时的系统响应方式
LPCSTR lpBinaryPathName, // 驱动/服务可执行文件路径
LPCSTR lpLoadOrderGroup, // 所属分组,决定加载顺序
LPDWORD lpdwTagId, // 输出的标签值(排序用)
LPCSTR lpDependencies, // 所依赖的服务列表(以双 \0 结尾)
LPCSTR lpServiceStartName, // 服务启动账户(驱动设为 NULL)
LPCSTR lpPassword // 启动账户的密码(驱动设为 NULL)
);hSCManager
:由OpenSCManager
返回的句柄。lpServiceName
:服务的逻辑名称,对应注册表子项名,必须唯一。lpDisplayName
:显示名称,出现在服务管理器界面中。dwDesiredAccess
:服务句柄的访问权限,推荐SERVICE_ALL_ACCESS
。dwServiceType
:服务类型。驱动应设为SERVICE_KERNEL_DRIVER
(值0x1
)。dwStartType
:启动方式:SERVICE_BOOT_START
(0)→ 引导加载SERVICE_SYSTEM_START
(1)→ 内核加载SERVICE_DEMAND_START
(3)→ 手动加载
dwErrorControl
:启动失败时系统行为:SERVICE_ERROR_IGNORE
(0)→ 忽略错误SERVICE_ERROR_NORMAL
(1)→ 记录日志SERVICE_ERROR_SEVERE
(2)→ 启动安全模式
lpBinaryPathName
:驱动路径(如"C:\\Drivers\\MyDriver.sys"
)。lpLoadOrderGroup
:加载分组(如Base
,影响加载顺序,可为 NULL)。lpdwTagId
:输出值,指定分组内的排序标识(可为 NULL)。lpDependencies
:依赖服务名称,多个用\0
分隔,以\0\0
结尾。lpServiceStartName
:服务启动账户,驱动设为NULL
表示 LocalSystem。lpPassword
:账户密码,驱动设为NULL
。
对于我们测试的驱动,
CreateService
示例传参如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15CreateService(
hSCManager, // 服务控制管理器句柄(来自 OpenSCManager)
"MyDriver", // 服务名称(注册表键名,必须唯一)
"My Kernel Driver", // 服务显示名称(可在服务管理器中显示)
SERVICE_ALL_ACCESS, // 访问权限(允许所有操作:启动、停止、删除等)
SERVICE_KERNEL_DRIVER, // 服务类型:内核模式驱动(对应 .sys 文件)
SERVICE_DEMAND_START, // 启动类型:按需启动(需手动调用 StartService)
SERVICE_ERROR_NORMAL, // 错误控制:加载失败时记录日志,继续启动系统
"C:\\Drivers\\MyDriver.sys", // 驱动程序路径(必须为绝对路径)
NULL, // 加载顺序组(不指定)
NULL, // Tag ID 输出参数(排序标识,不需要时设 NULL)
NULL, // 依赖服务列表(无依赖)
NULL, // 启动账户(驱动使用 LocalSystem,设为 NULL)
NULL // 启动账户密码(同上,设为 NULL)
);OpenService
:打开已存在的服务(或驱动),获取用于后续控制(启动、停止、删除)的句柄。1
2
3
4
5SC_HANDLE OpenService(
SC_HANDLE hSCManager, // 来自 OpenSCManager 的 SCM 句柄
LPCSTR lpServiceName, // 要打开的服务名
DWORD dwDesiredAccess // 所需权限(如 SERVICE_START | STOP)
);hSCManager
:由OpenSCManager
获取的 SCM 句柄。lpServiceName
:服务名称,必须精确匹配已注册服务名。dwDesiredAccess
:访问权限(如SERVICE_START | SERVICE_STOP | DELETE
)。
StartService
:启动指定服务或驱动。对于驱动,会由 SCM 调用NtLoadDriver
,将.sys
文件加载到内核。1
2
3
4
5BOOL StartService(
SC_HANDLE hService, // 目标服务的句柄
DWORD dwNumServiceArgs, // 参数个数(驱动设为 0)
LPCSTR *lpServiceArgVectors // 参数数组(驱动设为 NULL)
);hService
:来自CreateService
或OpenService
的服务句柄。dwNumServiceArgs
:参数个数,驱动无参数则设为0
。lpServiceArgVectors
:参数数组,驱动无参数则设为NULL
。
ControlService
:向运行中的服务发送控制命令。用于停止驱动(需驱动实现Unload
函数)。1
2
3
4
5BOOL ControlService(
SC_HANDLE hService, // 服务句柄
DWORD dwControl, // 控制命令(如 SERVICE_CONTROL_STOP)
LPSERVICE_STATUS lpServiceStatus // 输出当前服务状态
);hService
:目标服务句柄。dwControl
:控制命令,停止服务时设为SERVICE_CONTROL_STOP
(0x1)。lpServiceStatus
:接收服务状态的结构体指针。
DeleteService
:删除指定服务或驱动注册信息(从注册表清除),不会立即卸载已加载驱动。1
2
3BOOL DeleteService(
SC_HANDLE hService // 目标服务句柄
);hService
:目标服务句柄,需具有DELETE
权限。
CloseServiceHandle
:关闭服务或 SCM 句柄,释放资源。1
2
3BOOL CloseServiceHandle(
SC_HANDLE hSCObject // 可为服务句柄或 SCM 句柄
);hSCObject
:服务或控制管理器的句柄。
示例代码
1 |
|
相关命令
SCM(Service Control Manager)方式加载驱动,除了直接用 WinAPI 外,Windows 提供了标准命令行工具。
sc
是 Windows 提供的服务控制命令行工具,全名为 Service Control。它支持创建、启动、停止、删除内核驱动服务。
创建服务(注册驱动)
1
sc create MyDriver type= kernel binPath= "C:\Path\To\MyDriver.sys"
MyDriver
:驱动服务名(服务项名称)type= kernel
:表示是内核驱动(不可省略)binPath= ...
:驱动文件路径(推荐绝对路径)
注意
type=
,binPath=
后必须留空格,语法严格。启动驱动服务(实际加载)
会触发 Service Control Manager 调用
NtLoadDriver
加载.sys
文件,驱动的DriverEntry
将被执行。1
sc start MyDriver
停止驱动服务(触发卸载)
要求驱动实现了
DriverUnload
函数,否则会失败。1
sc stop MyDriver
删除服务(清除注册表项)
1
sc delete MyDriver
ZwLoadDriver(系统调用 + 注册表)
这是更“底层”的方式,绕过 SCM,直接调用内核的 ZwLoadDriver
系统服务加载驱动。常用于调试工具、PoC 框架、测试加载器或绕过方式。
原理流程
首先用户态程序提前创建注册表项(路径一般为):
HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\MyDriver
其中必须设置至少两个关键键值:
ImagePath
(REG_EXPAND_SZ
):驱动文件路径,如:\??\C:\Path\to\MyDriver.sys
Type
(DWORD
):必须为1
,表示该服务为内核驱动。
构造 NT 路径并调用
ZwLoadDriver
加载驱动,该函数原型如下:1
2
3NTSYSAPI NTSTATUS NTAPI ZwLoadDriver(
IN PUNICODE_STRING DriverServiceName
);- 参数:注册表路径,格式为:
\Registry\Machine\System\CurrentControlSet\Services\MyDriver
- 返回值:NTSTATUS 错误码,常见:
STATUS_SUCCESS
:成功STATUS_OBJECT_NAME_NOT_FOUND
:注册表路径错误STATUS_IMAGE_ALREADY_LOADED
:已加载
提示
ZwLoadDriver
和NtLoadDriver
实际上是同一个函数的两个符号。在 Windows 内核设计中,
NtXxx
和ZwXxx
实际上代表的是同一个系统服务接口(Syscall)函数,但它们存在 调用上下文(user mode vs kernel mode)下的行为差异 和 API 访问路径差异,而 在用户态时,它们几乎是完全等价的入口符号。- 参数:注册表路径,格式为:
与驱动加载类似,卸载驱动的时候需要调用
ZwUnloadDriver
,并传入同样格式的注册表路径。注意
驱动必须自己实现
DriverUnload
回调,系统才会调用卸载。ZwUnloadDriver
函数原型如下:1
2
3NTSYSAPI NTSTATUS NTAPI ZwUnloadDriver(
IN PUNICODE_STRING DriverServiceName
);- 参数同上,指定已加载驱动的注册表路径;
- 若驱动未实现
DriverUnload
,调用将失败(一般是STATUS_INVALID_DEVICE_REQUEST
)。
清理注册表项。
ZwUnloadDriver
不会自动删除注册表项 ,即驱动从内核卸载后,注册表中的服务项仍然存在,必须你手动清理。
示例代码
1 |
|
驱动开发基础
基本代码
通常一个最基本的 WDM 驱动代码如下:
1 |
|
其中 DriverEntry
是 Windows 驱动程序的主入口函数(Entry Point),等同于用户程序中的 main()
函数。
1 | NTSTATUS DriverEntry( |
当驱动加载时,系统会调用此函数来完成驱动的初始化过程。
PDRIVER_OBJECT DriverObject
:内核为每个加载的驱动创建一个DRIVER_OBJECT
结构,此参数就是它的指针。你需要通过它来注册 IRP 分发表、卸载函数、创建设备等。DRIVER_OBJECT
是 Windows 内核用来描述一个驱动程序核心信息的数据结构,驱动开发时我们通过它设置入口函数、分发表、卸载逻辑,是驱动生命周期管理的中心。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18//0xA8 bytes
struct _DRIVER_OBJECT {
CSHORT Type; // 内核对象类型 (固定 DRIVER_OBJECT(0x04))
CSHORT Size; // 结构体大小 (0xA8 字节)
struct _DEVICE_OBJECT* DeviceObject; // 📌设备对象链表头
ULONG Flags; // 驱动状态标志
PVOID DriverStart; // 📌驱动映像起始地址
ULONG DriverSize; // 📌驱动映像总大小
PVOID DriverSection; // 📌加载模块节点,KLDR_DATA_TABLE_ENTRY结构 (挂载到 PsLoadedModuleList)
struct _DRIVER_EXTENSION* DriverExtension; // 扩展区域 (含 AddDevice)
UNICODE_STRING DriverName; // 📌驱动名 (\Driver\XXX)
UNICODE_STRING* HardwareDatabase; // 硬件数据库路径 (历史用途)
PFAST_IO_DISPATCH FastIoDispatch; // 快速 I/O 分发表 (文件系统驱动用)
PDRIVER_INITIALIZE DriverInit; // 📌初始化入口 (内部使用)
PDRIVER_STARTIO DriverStartIo; // 串行化 I/O 支持 (极少用)
PDRIVER_UNLOAD DriverUnload; // 📌驱动卸载函数指针
PDRIVER_DISPATCH MajorFunction[28]; // 📌IRP 主功能分发表
};PUNICODE_STRING RegistryPath
:指向一个 Unicode 字符串,表示驱动在注册表中的键路径,如\Registry\Machine\System\CurrentControlSet\Services\MyDriver
。
在 DriverEntry
中,我们主要做一些初始化的操作,比如创建设备对象,初始化全局变量,注册 IRP 分发表等等。
注意
在
DriverEntry
中要的是设置驱动卸载函数DriverObject->DriverUnload
,如果这一步没有做则驱动无法卸载。只有全部成功后才返回
STATUS_SUCCESS
,否则系统自动撤销加载。因此我们不需要担心在DriverEntry
中由于出错提前返回没有设置设置驱动卸载函数而导致驱动无法卸载,因为驱动根本就没有加载成功。
功能技巧
返回值
几乎所有内核 API 和驱动入口函数都使用 NTSTATUS
类型作为返回值。
1 | typedef LONG NTSTATUS; |
同时 WDK 中提供了几个宏用根据返回值判断 api 调用结果。
宏函数 | 作用 |
---|---|
NT_SUCCESS(Status) |
判断是否成功(高位为 0) |
NT_ERROR(Status) |
判断是否是错误(高位为 1) |
NT_WARNING(Status) |
判断是否是警告 |
因此一个标准的 API 调用的返回值检测应该是下面这种写法:
1 | NTSTATUS MyFunction() |
日志输出
内核调试输出需要使用专门的 api,并且输出内容走的是 DbgPrint Buffer
,通常只有连接 WinDbg / KD 等调试器时可实时显示;无内核调试连接时,有些版本仍可借助 DebugView(SysInternals 工具)捕获部分内核日志。
DbgPrint
:最基本的内核调试输出函数,用法与printf
类似,默认输出优先级较低,相当于DbgPrintEx(DPFLTR_DEFAULT_ID, DPFLTR_INFO_LEVEL, ...)
。1
2
3
4ULONG DbgPrint(
PCSTR Format, // 格式化字符串,类似 printf()
... // 可变参数
);DbgPrintEx
:DbgPrint
的增强版,允许指定组件类别和日志等级,便于在复杂项目中分类控制输出。1
2
3
4
5
6ULONG DbgPrintEx(
ULONG ComponentId, // 模块分类 (WDF 框架建议填写 DPFLTR_DRIVER_FRAMEWORK_ID)
ULONG Level, // 日志级别 (DPFLTR_XXX_LEVEL)
PCSTR Format, // 格式化字符串
... // 可变参数
);这里常见
ComponentId
值有:DPFLTR_DEFAULT_ID
:默认组件DPFLTR_IO_ID
:I/O 子系统DPFLTR_PNP_ID
:PnP 子系统DPFLTR_DRIVER_FRAMEWORK_ID
:WDF 框架日志
常见的
Level
值有:DPFLTR_INFO_LEVEL
:普通信息DPFLTR_WARNING_LEVEL
:警告DPFLTR_ERROR_LEVEL
:错误DPFLTR_MASK
:所有级别
KdPrintEx
:实际上是对DbgPrintEx
的宏封装,编写格式需使用两层括号,优势在于统一兼容内核版本控制,WDK 推荐使用,另外可以在 Release 版本自动去除日志输出。1
KdPrintEx((DPFLTR_DEFAULT_ID, DPFLTR_INFO_LEVEL, "MyDriver running.\n"));
断点
DbgBreakPoint()
是 Windows 内核提供的标准调试断点函数,专门用于驱动或内核模块中设置断点。它的作用是:当内核代码执行到 DbgBreakPoint()
处时,如果系统当前处于调试状态(例如 WinDbg 已附加),将触发调试器断点中断。
DbgBreakPoint()
是由内核导出的函数,声明如下:
1 | VOID NTAPI DbgBreakPoint(VOID); |
kdBreakPoint
返回地址
数据结构
字符串
字符串类型
在 Windows 开发中有多种字符串类型,但是在内核驱动开发中为了安全起见,有额外引入了 UNICODE_STRING
这一新的字符串类型。
类型 | 说明 | 使用场景 |
---|---|---|
UNICODE_STRING |
UTF-16 编码,结构体包装 | 内核中最常见的字符串类型,用于路径、设备名、对象名等 |
WCHAR[] |
C 风格宽字符串(null结尾) | 常用于初始化 UNICODE_STRING |
CHAR[] |
C 风格窄字符串(null结尾) | 常用于初始化 ANSI_STRING |
PWSTR / PCHAR |
指向上述数组的指针 | 宽/窄字符数组地址,传参常用 |
UNICODE_STRING
UNICODE_STRING
字符串类型本质上就是将宽字符串利用一个结构体进行了一次封装。
1 | typedef struct _UNICODE_STRING { |
注意
Length
单位是字节,不是字符数;Buffer
不强制null
结尾;
UNICODE_STRING
可以通过 RtlInitUnicodeString
函数和 RTL_CONSTANT_STRING
宏两种方式进行初始化。
注意
这两种初始化方法都不会拷贝字符串内容,而只设置结构体,指针仍指向原始常量字符串。
RtlInitUnicodeString
函数原型如下:1
2
3
4VOID RtlInitUnicodeString(
PUNICODE_STRING DestinationString,
PCWSTR SourceString
);- 该函数会设置结构体的
Length
,MaximumLength
,Buffer
字段。 SourceString
必须是 null 结尾的常量或合法缓冲区
示例代码:
1
2UNICODE_STRING uStr;
RtlInitUnicodeString(&uStr, L"\\Device\\MyDriver");- 该函数会设置结构体的
RTL_CONSTANT_STRING
RTL_CONSTANT_STRING
用于编译期静态构造一个UNICODE_STRING
,该宏的定义如下:1
示例代码:
1
UNICODE_STRING path = RTL_CONSTANT_STRING(L"\\Device\\MyDriver");
注意
RTL_CONSTANT_STRING
不能用于变量字符串,只能用于编译期可见的字符串常量(即L"..."
形式的字面量)。如果你错误地用它去初始化一个运行时变量,会导致:结构体字段内容不正确(长度计算可能错误)
潜在的内存越界访问
编译器不报错,但运行时行为未定义
字符串转换
在实际开发中,经常会遇到将用户传入的 ANSI 字符串转换为内核 API 可用格式这种需求,这就需要我们将 char *
字符串转换为 UNICODE_STRING
类型,具体步骤如下:
首先我们需要利用
RtlInitAnsiString
函数将char *
字符串转换为ANSI_STRING
类型:1
2
3
4char* ansi = "MyDevice\\Test";
ANSI_STRING ansiStr;
RtlInitAnsiString(&ansiStr, ansi);提示
在有些教程中这一步会使用
RtlInitString
函数将将char *
字符串转换为STRING
类型,实际上这里的STRING
类型实际上就是ANSI_STRING
的旧别名,结构相同。使用
RtlAnsiStringToUnicodeString
函数将ANSI_STRING
字符串转换为UNICODE_STRING
字符串。这里RtlAnsiStringToUnicodeString
函数原型如下:1
2
3
4
5NTSTATUS RtlAnsiStringToUnicodeString(
PUNICODE_STRING DestinationString,
PCANSI_STRING SourceString,
BOOLEAN AllocateDestinationString
);DestinationString
:输出的 Unicode 结构体SourceString
:输入的 ANSI 结构体AllocateDestinationString
:如果为TRUE
,系统会分配DestinationString->Buffer
;否则你必须事先分配好ANSI_STRING.Buffer
并设置MaximumLength
,否则可能崩溃或数据丢失。
这里为了方便封装,我们采用
AllocateDestinationString
为TRUE
的写法。对于这样产生的UNICODE_STRING
字符串,使用完毕时候我们需要调用RtlFreeUnicodeString
函数将其释放,这里释放的是ANSI_STRING.Buffer
。
在实际开发中,我们一般习惯将上述步骤封装成一个函数:
1 | NTSTATUS ConvertAnsiToUnicode(_In_ const char* input, _Out_ PUNICODE_STRING uStr) { |
常用函数
RtlStringCbPrintfA/W
:内核安全字符串格式化函数1
2
3
4
5
6
7
8
9
10
11
12
13NTSTATUS RtlStringCbPrintfA(
_Out_ CHAR *pszDest,
_In_ size_t cbDest,
_In_ const CHAR *pszFormat,
...
);
NTSTATUS RtlStringCbPrintfW(
_Out_ WCHAR *pszDest,
_In_ size_t cbDest,
_In_ const WCHAR *pszFormat,
...
);pszDest
:输出目标缓冲区cbDest
:缓冲区总字节数(注意单位:字节,不是字符数)pszFormat
:格式化字符串,类似printf
格式
RtlCompareUnicodeString
:UNICODE_STRING
安全比较1
2
3
4
5LONG RtlCompareUnicodeString(
_In_ const UNICODE_STRING *String1,
_In_ const UNICODE_STRING *String2,
_In_ BOOLEAN CaseInSensitive
);CaseInSensitive
:是否大小写无关(TRUE
表示忽略大小写)- 返回值:返回逻辑类似 C 标准库
strcmp
。0
:相等<0
:String1
小于String2
>0
:String1
大于String2
双向链表(LIST_ENTRY)
在 Windows 中有一个专门描述链表节点的结构 LIST_ENTRY
,该结构定义如下:
1 | typedef struct _LIST_ENTRY { |
在 Windows 的设计思想中,双向链表有两种成员组成:
ListHead
:即链表头,通常类型为LIST_ENTRY
结构体,有时会作为一个成员放到另一个结构体中,但是作为“链表头”本身仍是LIST_ENTRY
类型。链表头自己不存储任何数据,只是链表控制块,但会被串到双向链表中。1
2LIST_ENTRY MyList;
InitializeListHead(&MyList);注意
Windows 的
LIST_ENTRY
初始状态必须是:1
2ListHead->Flink = ListHead;
ListHead->Blink = ListHead;任何链表必须先初始化,否则后续操作容易蓝屏。Windows有一个专门用于初始化
ListEntry
的函数InitializeListHead
。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22/**
* @brief 初始化链表头部,设置链表为空。
*
* 该函数将链表头部的 Flink 和 Blink 指针都指向链表头本身,
* 以表示该链表为空。空链表的头部不指向任何有效节点,确保
* 链表操作的一致性。
*
* @param ListHead 指向链表头部的指针。
*
* @return 无返回值。
*/
FORCEINLINE
VOID
InitializeListHead(
_Out_ PLIST_ENTRY ListHead ///< 指向链表头部的指针
)
{
// 将链表头部的 Flink 和 Blink 都指向链表头本身,表示该链表为空
ListHead->Flink = ListHead->Blink = ListHead;
return; // 函数执行完成,不需要返回值
}Entry
:用来将节点链入双向链表中的一个结构体成员,类型同样为LIST_ENTRY
。例如下面这个结构体中的List
就是一个Entry
,我们可以通过从链表头遍历双向链表找到所有链表中的MY_NODE
结构体。1
2
3
4typedef struct _MY_NODE {
ULONG ID;
LIST_ENTRY List;
} MY_NODE;注意
我们通过双向链表遍历找到的结构体地址实际上是
List
成员的地址,要想获取到结构体地址还需要借助CONTAINING_RECORD
宏。1
2例如:
1
2PLIST_ENTRY pEntry = RemoveHeadList(&MyList);
PMY_NODE pNode = CONTAINING_RECORD(pEntry, MY_NODE, List);
针对双向链表,Windows 提供了众多 API 用于操作双向链表中的成员:
InsertHeadList()
:将Entry
插入到链表头部(头节点后,ListHead->Flink
方向)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45/**
* @brief 将一个节点插入到链表头部。
*
* 该函数将指定的节点插入到链表的头部,将节点放置到链表头部之后,
* 使其成为链表的第一个元素。插入时,更新相关节点的 Flink 和 Blink 指针。
*
* @param ListHead 指向链表头部的指针。
* @param Entry 要插入的节点指针。该节点将成为链表头部的下一个节点。
*
* @return 无返回值。
*
* @note 此函数会检查链表的完整性,并且在调试模式下进行链表一致性检查。
*/
FORCEINLINE
VOID
InsertHeadList(
_Inout_ PLIST_ENTRY ListHead, ///< 链表头部指针,输入输出参数
_Out_ __drv_aliasesMem PLIST_ENTRY Entry ///< 要插入的节点,输出参数
)
{
PLIST_ENTRY NextEntry;
// 调试模式下,检查链表的完整性
RtlpCheckListEntry(ListHead);
// 获取链表头部后继节点
NextEntry = ListHead->Flink;
// 检查链表一致性,确保后继节点的 Blink 指针指向链表头部
if (NextEntry->Blink != ListHead) {
FatalListEntryError((PVOID)ListHead,
(PVOID)NextEntry,
(PVOID)NextEntry->Blink);
}
// 将新节点插入到链表头部
Entry->Flink = NextEntry; // 新节点的 Flink 指向原头部的下一个节点
Entry->Blink = ListHead; // 新节点的 Blink 指向链表头部
NextEntry->Blink = Entry; // 原头部的下一个节点的 Blink 指向新节点
ListHead->Flink = Entry; // 链表头部的 Flink 指向新节点
return; // 函数执行完成,无需返回值
}InsertTailList()
:插入到链表尾部(头节点前,ListHead->Blink
方向)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47/**
* @brief 将一个节点插入到链表尾部。
*
* 该函数将指定的节点插入到链表的尾部。插入时,会更新链表的前驱和后继节点的 Flink 和 Blink 指针,
* 使得新节点成为链表的最后一个节点。
*
* @param ListHead 指向链表头部的指针。该参数在函数中被修改,表示链表的头部。
* @param Entry 要插入的节点指针。该节点将被插入到链表的尾部。
*
* @return 无返回值。
*
* @note 链表一致性检查:在插入节点之前,函数会检查链表的完整性,确保链表结构没有损坏。
*/
FORCEINLINE
VOID
InsertTailList(
_Inout_ PLIST_ENTRY ListHead, ///< 链表头部指针,输入输出参数
_Out_ __drv_aliasesMem PLIST_ENTRY Entry ///< 要插入的节点,输出参数
)
{
PLIST_ENTRY PrevEntry; // 当前链表尾部的前驱节点
// 调试模式下,检查链表一致性
RtlpCheckListEntry(ListHead);
// 获取链表尾部的前驱节点
PrevEntry = ListHead->Blink;
// 检查链表一致性,确保链表尾部的前驱节点的 Flink 正确指向链表头部
if (PrevEntry->Flink != ListHead) {
// 如果链表结构不一致,调用 FatalListEntryError 处理错误
FatalListEntryError((PVOID)PrevEntry,
(PVOID)ListHead,
(PVOID)PrevEntry->Flink);
}
// 将新节点插入到链表尾部
Entry->Flink = ListHead; // 新节点的 Flink 指向链表头部
Entry->Blink = PrevEntry; // 新节点的 Blink 指向链表尾部的前驱节点
PrevEntry->Flink = Entry; // 原尾部的前驱节点的 Flink 指向新节点
ListHead->Blink = Entry; // 链表头部的 Blink 指向新节点
return; // 函数执行完成,不需要返回值
}RemoveEntryList()
:从链表中删除指定节点。注意
从链表删除的节点的
Flink
和Blink
没有进行任何修改,因此如果使用这个函数实现断链隐藏操作,需要额外调用InitializeListHead
进行初始化,否则容易导致后续通不过链表相关检查导致蓝屏。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40/**
* @brief 从链表中移除一个节点。
*
* 该函数将指定的节点从链表中移除,更新链表中前驱和后继节点的指针。
* 在移除节点之前,函数会检查链表的一致性,确保链表结构正确。
*
* @param Entry 要从链表中移除的节点指针。
*
* @return 如果链表只剩下一个节点(即前驱和后继节点相同),返回 TRUE;否则返回 FALSE。
*
* @note 如果链表出现不一致,函数会调用 `FatalListEntryError` 来处理错误。
*/
FORCEINLINE
BOOLEAN
RemoveEntryList(
_In_ PLIST_ENTRY Entry // 要从链表中移除的节点
)
{
PLIST_ENTRY PrevEntry; // 当前节点的前驱节点
PLIST_ENTRY NextEntry; // 当前节点的后继节点
// 获取当前节点的前驱和后继节点
NextEntry = Entry->Flink;
PrevEntry = Entry->Blink;
// 检查链表一致性:前驱节点的 Flink 和后继节点的 Blink 是否正确指向当前节点
if ((NextEntry->Blink != Entry) || (PrevEntry->Flink != Entry)) {
// 如果链表存在不一致,调用错误处理函数
FatalListEntryError((PVOID)PrevEntry,
(PVOID)Entry,
(PVOID)NextEntry);
}
// 从链表中移除当前节点
PrevEntry->Flink = NextEntry; // 将前驱节点的 Flink 指向后继节点
NextEntry->Blink = PrevEntry; // 将后继节点的 Blink 指向前驱节点
// 如果前驱节点和后继节点指向的是同一个节点,说明链表只剩下当前一个节点,返回 TRUE
return (BOOLEAN)(PrevEntry == NextEntry);
}RemoveHeadList()
/RemoveTailList()
:移除链表头部第一个(最后一个)元素。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83/**
* @brief 移除链表头部的节点。
*
* 该函数将移除链表的第一个节点(头部节点的下一个节点),更新链表的头部指针。
* 移除节点后,链表的第一个节点将变为原头部的下一个节点。
*
* @param ListHead 指向链表头部的指针。
*
* @return 返回被移除的节点。
*
* @note 链表一致性检查:函数在移除节点之前会检查链表的一致性,确保链表结构没有损坏。
*/
FORCEINLINE
PLIST_ENTRY
RemoveHeadList(
_Inout_ PLIST_ENTRY ListHead ///< 链表头部指针,输入输出参数
)
{
PLIST_ENTRY Entry; // 要移除的节点
PLIST_ENTRY NextEntry; // 当前节点的下一个节点
Entry = ListHead->Flink; // 获取链表头部的下一个节点
// 调试模式下,检查链表一致性
RtlpCheckListEntry(ListHead);
NextEntry = Entry->Flink; // 获取下一个节点
// 检查链表一致性:当前节点的 Blink 和下一个节点的 Blink 是否正确
if ((Entry->Blink != ListHead) || (NextEntry->Blink != Entry)) {
// 如果链表有错误,调用 FatalListEntryError 处理
FatalListEntryError((PVOID)ListHead, (PVOID)Entry, (PVOID)NextEntry);
}
// 更新链表头部指针,移除当前节点
ListHead->Flink = NextEntry;
NextEntry->Blink = ListHead;
return Entry; // 返回被移除的节点
}
/**
* @brief 移除链表尾部的节点。
*
* 该函数将移除链表的最后一个节点(尾部节点的前一个节点),更新链表的尾部指针。
* 移除节点后,链表的最后一个节点将变为原尾部的前一个节点。
*
* @param ListHead 指向链表头部的指针。
*
* @return 返回被移除的节点。
*
* @note 链表一致性检查:函数在移除节点之前会检查链表的一致性,确保链表结构没有损坏。
*/
FORCEINLINE
PLIST_ENTRY
RemoveTailList(
_Inout_ PLIST_ENTRY ListHead ///< 链表头部指针,输入输出参数
)
{
PLIST_ENTRY Entry; // 要移除的节点
PLIST_ENTRY PrevEntry; // 当前节点的前一个节点
Entry = ListHead->Blink; // 获取链表尾部的前一个节点
// 调试模式下,检查链表一致性
RtlpCheckListEntry(ListHead);
PrevEntry = Entry->Blink; // 获取前一个节点
// 检查链表一致性:当前节点的 Flink 和前一个节点的 Flink 是否正确
if ((Entry->Flink != ListHead) || (PrevEntry->Flink != Entry)) {
// 如果链表有错误,调用 FatalListEntryError 处理
FatalListEntryError((PVOID)PrevEntry, (PVOID)Entry, (PVOID)ListHead);
}
// 更新链表尾部指针,移除当前节点
ListHead->Blink = PrevEntry;
PrevEntry->Flink = ListHead;
return Entry; // 返回被移除的节点
}IsListEmpty()
:检查链表是否为空。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21/**
* @brief 检查链表是否为空。
*
* 该函数检查给定的链表是否为空。链表为空时,链表头部的 Flink 指针会指向链表头部本身。
*
* @param ListHead 指向链表头部的指针。
*
* @return 如果链表为空,返回 TRUE;否则返回 FALSE。
*
* @note 链表为空时,头部的 Flink 指针会指向链表头部本身,而不是其他节点。
*/
_Must_inspect_result_
BOOLEAN
CFORCEINLINE
IsListEmpty(
_In_ const LIST_ENTRY * ListHead ///< 指向链表头部的指针
)
{
// 如果链表头部的 Flink 指向链表头部本身,说明链表为空
return (BOOLEAN)(ListHead->Flink == ListHead);
}
通用平衡树框架(RTL_GENERIC_TABLE)
RTL_GENERIC_TABLE
提供通用平衡树框架(Windows 早期是 Splay;Win7+ 全部切换为 AVL),支持 按键快速查找/插入/删除。
RTL_GENERIC_TABLE
的初始化函数 RtlInitializeGenericTable
定义如下:
1 | NTSTATUS RtlInitializeGenericTable( |
Table
:用于保存初始化后的通用表结构体。你需要先定义好RTL_GENERIC_TABLE
结构体,把地址传进来,内核将填充其中的指针和配置字段。CompareRoutine
:比较函数指针,内核在插入/查找/删除时调用此函数以判定 Key 大小顺序。你需要实现此函数来定义排序规则,返回值为GenericLessThan
/GenericGreaterThan
/GenericEqual
。回调函数声明如下:1
2
3
4
5typedef RTL_GENERIC_COMPARE_RESULTS (*PRTL_GENERIC_COMPARE_ROUTINE)(
PRTL_GENERIC_TABLE Table,
PVOID FirstStruct,
PVOID SecondStruct
);AllocateRoutine
:分配函数指针,在插入新节点时,内核通过此函数为节点分配内存。通常传入封装好的ExAllocatePoolWithTag()
。1
2
3
4typedef PVOID (*PRTL_GENERIC_ALLOCATE_ROUTINE)(
PRTL_GENERIC_TABLE Table,
CLONG ByteSize
);FreeRoutine
:释放函数指针,当删除节点时内核通过此函数释放节点内存。通常封装调用ExFreePool()
。1
2
3
4typedef VOID (*PRTL_GENERIC_FREE_ROUTINE)(
PRTL_GENERIC_TABLE Table,
PVOID Buffer
);TableContext
:上下文指针,供你在比较函数 / 分配函数内部做额外业务逻辑用(可选,可以传NULL
)。例如存放配置信息、同步锁、日志上下文等。
另外 RTL_GENERIC_TABLE
提供了一系列的成员操作函数:
API | 功能 | 复杂度 |
---|---|---|
RtlInsertElementGenericTable |
若无重复,则插入新节点并返回指针 | O(log N) |
RtlLookupElementGenericTable |
按键查找 | O(log N) |
RtlDeleteElementGenericTable |
删除节点 | O(log N) |
RtlEnumerateGenericTable |
按字典序迭代 | O(1) 步进 |
RtlEnumerateGenericTableWithoutSplaying |
枚举但不再平衡 | Win7+ AVL 下同样平衡;保留兼容 |
RtlGetElementGenericTable |
按序号 (0..N-1) 获取 | O(N) |
RtlNumberGenericTableElements |
统计元素个数 | O(1) |
RtlIsGenericTableEmpty |
是否为空 | O(1) |
示例代码:
1 |
|
驱动常用代码
创建线程
在内核中通常使用 PsCreateSystemThread
函数创建内核线程,该函数原型如下:
1 | NTSTATUS PsCreateSystemThread( |
ThreadHandle
:函数成功返回时,输出新创建线程的句柄。- 驱动一般创建完线程立即关闭句柄,因为不需要持续持有。
- 必须在成功创建后调用
ZwClose()
关闭句柄,否则可能会泄漏句柄表项。 - 即使关闭句柄,线程本身仍在运行,句柄只是内核对象的一个引用。
DesiredAccess
:指定希望线程句柄具有的访问权限。因内核线程通常不操作自身句柄,直接用THREAD_ALL_ACCESS
或 0 都可。ObjectAttributes
:定义线程对象的名称、属性等。仅极少情况才会用,例如为线程创建命名对象供调试器附加。绝大部分内核驱动开发直接传NULL
。ProcessHandle
:指定新线程在哪个进程空间中运行。- 传
NULL
表示创建的是内核线程(属于系统进程System
,PID=4)。 - 若传入用户进程句柄,则创建用户进程中的远程线程。
- 传
ClientId
:可选输出参数,返回新创建线程的唯一标识(进程 ID + 线程 ID)。如果不需要则传NULL
即可。StartRoutine
:线程的入口函数指针(回调函数),函数原型为:1
VOID StartRoutine(PVOID StartContext);
- 必须确保该函数永远不返回,最后用
PsTerminateSystemThread()
主动结束线程。 StartContext
参数由第七个参数传入,方便传递上下文数据。
- 必须确保该函数永远不返回,最后用
StartContext
:传入给入口函数的自定义参数,通常为结构体指针或简单数据,用于向新线程传递启动上下文信息(如配置、句柄、共享内存等)。
示例代码如下:
1 |
|
内存强写
基于 CR0
CR0 寄存器的 WP(bit 16)控制写保护机制:
- WP = 1:开启写保护,无法写入只读页;
- WP = 0:允许内核修改只读页(比如
.text
段、系统表等)。
注意
关闭 CR0.WP 确实会破坏 COW(Copy-On-Write)机制。在 CR0.WP 写保护关闭期间(即允许修改只读内存页),必须使用 _disable()
禁用中断、_enable()
恢复中断,否则可能会在中断处理过程中访问未受保护的页,导致系统崩溃(蓝屏)或不一致行为。
由于需要关闭中断,该行为 只应用于极短的 Patch 窗口,如 SSDT hook、NOP patch、Inline Hook 等,不建议长时间保持 WP 关闭。否则系统在这个 CPU 核心上会失去调度能力(时钟中断被屏蔽),导致系统卡死(中断不处理)。
1 | ULONG_PTR DisableWP() |
基于 MDL
MDL(Memory Descriptor List) 是驱动中用于描述一段物理内存的结构体,结合 MmMapLockedPagesSpecifyCache
可将目标物理页映射为内核可写的非缓存内存。
具体过程为:
- 将目标地址构造成 MDL;
- 锁页(或声明其为 NonPagedPool);
- 通过
MmMapLockedPagesSpecifyCache(..., MmNonCached)
获取可写映射; - 修改后解除映射并释放资源。
1 | /** |
对于内核的非分页内存,我们不需要使用 MmProbeAndLockPages
去验证并锁定页面,而是直接用 MmBuildMdlForNonPagedPool
快速构建 MDL,无需验证或锁页。这样可以提升效率。
1 | /** |
地址范围获取
基于 RtlPcToFileHeader
RtlPcToFileHeader
是 Windows 内核中一个非常有用的函数,用于根据某个地址(如代码或数据地址)确定其所属模块(image)的基地址。该函数定义如下:
1 | // |
基于 RtlPcToFileHeader
我们可以获取模块地址,然后再根据模块地址解析 PE 结构获取模块的范围。
1 | // |
提示
这里查询模块地址范围需要我们提供一个模块中的地址,我们可以利用 MmGetSystemRoutineAddress
函数获取一个模块中导出的函数的地址用来查询模块地址范围。
1 | /** |
基于 ZwQuerySystemInformation
1 |
|
特征码
特征码提取
特征码提取规则如下:
操作数结构类型 | 举例汇编 | 提取结果示意 | 策略说明 |
---|---|---|---|
o_reg, o_reg / o_reg, o_void / o_reg, o_phrase |
mov eax, ecx 、jmp eax |
8BC1 |
全保留(每个字节都精确匹配) |
o_reg, o_displ |
mov eax, [ecx+4] |
8B41* |
保留前两字节,后面是偏移,用 * |
o_displ, o_reg / o_displ, o_imm |
mov [ebp+8], ecx 、mov [ebp+4], 0x1 |
894D* / C745****** |
保留前两字节,其余通配 |
o_phrase, o_reg |
push dword ptr [eax] |
FF30 |
全保留(操作地址固定) |
其他复杂结构(含立即数、地址、偏移) | call dword ptr [esi+0Ch] |
FF15****** |
保留前缀字节,其余全部通配 |
指令以 FF/66/67 前缀开始 | FF 15 xx xx xx xx |
FF15****** |
保留前两个字节,其余通配 |
无法识别操作数结构 | N/A | XX**** |
保留一个或两个前缀字节,其余通配 |
1 | # coding=utf-8 |
特征码搜索
定义 SIGNATURE_PATTERN
结构用来描述搜索的特征。
1 | // 通配符判断宏:'*'、'?'、'.' 都表示任意单字节 |
SIGNATURE_PATTERN
通过 InitSignaturePattern
函数初始化。
1 | /** |
搜索时需要一个 SIGNATURE_PATTERN
数组,当所有特征匹配时返回匹配到的地址。
1 | /** |
驱动隐藏
隐藏思路
在 Windows 内核中,驱动模块在系统内部存在两个最关键的暴露点:
PsLoadedModuleList
:系统全局模块双向链表(记录所有已加载驱动模块)DriverObject
结构体:系统所有已注册的驱动对象(包含驱动模块信息)
因此隐藏的本质是:
- 从
PsLoadedModuleList
断链 → 让系统模块枚举 API 查不到 - 抹除
DriverObject
内关键字段 → 让安全软件和调试器无法逆推出模块信息
隐藏流程
- 延迟隐藏逻辑 :如果在
DriverEntry()
阶段立即隐藏,可能会因为内核后续调用尚未完成而引发异常。这是因为驱动加载的后续流程可能会用到DriverObject
结构体中一些对象。因此我们可以启动一个内核线程,延迟约 100ms 后再执行隐藏逻辑。 - 断链模块表 :遍历
PsLoadedModuleList
,逐个对比BaseDllName
与目标模块名。找到后执行RemoveEntryList()
完成断链。 - 筛选合法伪造模块 :在遍历链表时顺便记录第一个合法存在的其它模块节点。该节点用作伪造用
DriverSection
。 - 定位 DriverObject :使用内核 API
ObReferenceObjectByName()
定位目标驱动的DriverObject
。 - 抹除与伪造 :将
DriverInit
、DriverSection
、Type
字段抹除或伪造。其中DriverSection
需要执行前面筛选的合法DriverSection
,防止安全软件在扫描DriverObject->DriverSection
时蓝屏。
完整代码
1 |
|
驱动通信
设备对象
绝大多数情况下,一个内核驱动如果没有创建任何设备对象 (DeviceObject),那么用户态(Ring3)无法直接与该驱动通信。这是因为设备对象是内核通信入口,CreateFile
/ DeviceIoControl
/ ReadFile
/ WriteFile
等通信 API 只能打开设备对象。
设备对象主要有三类:
- PDO(Physical Device Object) :总线驱动创建,表示物理设备本身的存在性。例如 USB、PCI、SATA 控制器、蓝牙模块等。PDO 只描述:“有这么个硬件挂上来了”,不控制它如何工作。
- FDO(Functional Device Object) :功能驱动创建,负责控制硬件功能、提供核心业务逻辑。FDO 负责解释 IRP 请求、控制硬件寄存器、管理协议栈、提供用户空间接口,真正把硬件功能带给系统。
- Filter Device Object :过滤驱动创建,可插在 FDO 上下两侧,负责监控、修改、拦截 I/O 请求,属于透明扩展层。它不控制硬件,而是做中间层逻辑处理。
这些设备对象彼此层叠形成的一条逻辑设备处理链。用户与设备交互的数据在 I/O 层叠栈中的设备对象中层层转发,每一层都可以可以拦截、监控、修改、阻断用户请求。这种结构被称为 I/O 层叠栈(Stacked Device Stack)。
1 | 用户 I/O 请求 |
不过大多数普通第三方内核驱动开发者实际上写的都是类似 FDO 或 Filter,PDO 只能由 Bus Driver 创建(通常系统自带)。
设备类型 | PDO 创建者 | FDO 创建者 | Filter 创建者 |
---|---|---|---|
USB 存储 | USB Hub 驱动 | UAS 驱动(如 usbstor.sys) | 杀毒软件过滤层 |
网卡 | PCI Bus 驱动 | NIC 功能驱动 | 防火墙、抓包驱动 |
虚拟设备 | Root Enumerator | 虚拟驱动 | 监控、调试工具 |
设备对象与驱动对象的关系:
- 一个驱动可以创建(
IoCreateDevice
)或附加(IoAttachDevice
)多个设备对象,用于负责处理和过滤多个设备的消息。 - 一个物理设备可以绑定多个设备对象,这些设备对象形成了一个设备对象堆栈,可以层层过滤用户程序向设备发送的消息。
设备对象定义
1 | struct _DEVICE_OBJECT |
DriverObject
:所属驱动的DriverObject
,用于找到设备对象所属的驱动对象。NextDevice
:将一个驱动所属的所有设备对象DriverObject
串联成一个单向链表,链表头为DriverObject->DeviceObject
。1
2
3DriverObject
|
+--> DeviceObject1 --> DeviceObject2 --> DeviceObject3 --> NULLAttachedDevice
:AttachedDevice
构成的是跨驱动的 I/O 层叠栈(Stacked Device Stack)。它让不同驱动可以挂接在同一设备上层,共同参与 I/O 请求的流转与处理。设备对象的AttachedDevice
指向下一层的设备对象。1
2
3
4
5
6
7
8
9
10
11
12
13IRP 入口
↓
DeviceObject_Filter3 <-- 过滤层3(最上层)
↓ AttachedDevice
DeviceObject_Filter2 <-- 过滤层2
↓ AttachedDevice
DeviceObject_Filter1 <-- 过滤层1
↓ AttachedDevice
DeviceObject_Functional (FDO) <-- 功能设备对象 (目标核心驱动)
↓ AttachedDevice
DeviceObject_PDO <-- 物理设备对象 (底层物理设备)
↓ AttachedDevice
NULLStackSize
:从当前设备对象所在的位置,往下直到整个设备栈的最底层(PDO)为止,所需要的 IRP 栈帧数量总和。换句话说:当前设备对象在整个 IRP 传递链中,自己算在内,往下有多少设备对象要参与。1
2
3
4
5
6
7
8
9
10
11用户层 I/O 请求
↓
[DeviceObject_Filter3] StackSize=5
↓
[DeviceObject_Filter2] StackSize=4
↓
[DeviceObject_Filter1] StackSize=3
↓
[DeviceObject_FDO] StackSize=2
↓
[DeviceObject_PDO] StackSize=1
设备对象创建
在内核中,设备对象通常通过 IoCreateDevice
函数进行创建,该函数由 I/O 管理器提供,用于在 Object Manager 中注册新的设备对象,并分配相应的内存与扩展数据区。以下是函数原型:
1 | NTSTATUS IoCreateDevice( |
DriverObject
:指定所属驱动对象,通常传入DriverEntry
函数中的PDRIVER_OBJECT
,用于将新创建的设备对象挂接到驱动对象下,由驱动统一管理。DeviceExtensionSize
:指定设备扩展区的大小(以字节为单位)。内核会在分配DEVICE_OBJECT
结构时附加这一段额外空间,驱动可通过DeviceObject->DeviceExtension
访问此区域,用于存储与设备实例相关的自定义上下文信息。如果不需要扩展区则传0
。DeviceName
:指定设备对象的命名路径(完整的 Object Manager 路径),如\Device\MyDevice
。如果传入NULL
,则创建匿名设备对象,不注册命名空间,不可通过名称访问;一般控制型驱动需提供命名,供用户态程序通过符号链接访问。DeviceType
:指定设备对象类型,用于指明设备类别,内核使用该类型决定某些默认行为。例如:FILE_DEVICE_UNKNOWN
:默认通用类型,绝大多数控制型驱动使用;FILE_DEVICE_DISK
:磁盘设备;FILE_DEVICE_NETWORK
:网络设备;FILE_DEVICE_FILE_SYSTEM
:文件系统设备;- 其他类型视具体功能选用。
DeviceCharacteristics
:指定设备特性标志,用于控制设备的附加行为。常见取值包括:FILE_DEVICE_SECURE_OPEN
:启用安全性访问检查;FILE_REMOVABLE_MEDIA
:表示可移动介质;- 一般自定义控制型驱动可传
0
,表示不声明任何特殊设备行为,内核使用默认通用行为对待该设备对象。
Exclusive
:指定设备对象是否独占访问。当设为TRUE
时,系统仅允许一个线程/进程打开该设备对象,后续打开请求会失败(返回STATUS_DEVICE_BUSY
)。通常设为FALSE
,允许并发访问。DeviceObject
:输出参数,返回成功创建的设备对象指针。驱动可通过此指针访问扩展区、设置属性,并在卸载时配合IoDeleteDevice
正确释放内存资源。
然而 IoCreateDevice
只负责把设备对象的内存空间从内核池分配出来,做了最基本初始化,但没有对外暴露接口。因此我们还要注册符号链接建立 Win32 层访问路径,确保用户态能够通过 CreateFile()
调用访问。
例如我们将设备名为 \Device\MyDevice
的设备通过 IoCreateSymbolicLink
创建了一个到 \??\MyDevice
的软连接:
1 | // 设备命名:注册到 Object Manager 命名空间下 |
那么用户程序就可以通过设备路径 \\.\MyDevice
来操作这个设备了。
最后我们需要对 DeviceObject->Flags
清除初始化标志。这一步实际上是为了兼容一些老的操作系统,这一类操作系统不会自动去除初始化标志,导致设备对象创建之后始终处于未初始化状态,导致一些对设备的操作失败。
1 | // 设备初始化完成,清除初始化标志 (兼容老系统) |
示例代码如下:
1 |
|
设备对象附加
在 Windows 内核 I/O 框架中,驱动可以将自己编写的设备对象附加到现有的设备对象之上,形成设备对象堆栈(Device Stack)。这种附加行为常用于开发过滤驱动、监控驱动、保护驱动、文件过滤驱动等场景。
内核提供 IoAttachDevice
或 IoAttachDeviceToDeviceStack
函数用于实现附加操作。
IoAttachDevice
函数原型如下:
1 | PDEVICE_OBJECT IoAttachDevice( |
SourceDevice
:本驱动中用IoCreateDevice()
创建好的设备对象,作为过滤层插入堆栈;TargetDevice
:目标设备对象的全路径(如\Device\Harddisk0\DR0
),指定要附加到哪个设备;AttachedTo
:附加成功后返回目标设备对象指针,即被附加的设备对象。也就是说附加后SourceDevice->AttachedDevice = AttachedTo
。
IoAttachDeviceToDeviceStack
则需要我们直接提供要被附加的设备对象,而不是设备路径,该函数定义如下:
1 | PDEVICE_OBJECT IoAttachDeviceToDeviceStack( |
SourceDevice
:指定新创建的过滤层设备对象(一般通过IoCreateDevice()
创建),将被插入到设备堆栈顶端,成为新的栈顶对象。附加成功后,该对象位于整个设备栈最顶层,优先接收 IRP 请求。TargetDevice
:要附加的目标设备对象。系统会根据其AttachedDevice
自动遍历整个设备栈,找到当前栈顶位置然后附加。
当完成附加后,当用户态通过 CreateFile()
打开设备时,虽然传入的设备路径仍然是被附加的设备对象。
但是由于我们在内核中通过 IoAttachDeviceToDeviceStack()
已经把自己的设备对象挂入了目标设备对象的 I/O 栈顶,因此用户请求命中目标设备对象时,内核始终从栈顶开始派发 IRP。而我们的过滤设备对象就在这条栈上,所有请求自然会经过我们的驱动。
设备对象附加的示例代码如下:
1 | NTSTATUS AttachToTargetDevice(PDEVICE_OBJECT MyDevice) |
在驱动卸载的时候,我们需要在删除自己的设备对象之前先从设备栈分离。
1 | IoDetachDevice(MyDeviceExtension->NextDevice); |
IRP(I/O Request Packet)
IRP(I/O Request Packet)是 Windows 内核 I/O 子系统内部使用的统一请求数据结构,负责在设备驱动之间传递 I/O 操作请求。所有内核驱动层(文件系统驱动、网络驱动、过滤驱动、控制驱动等等)之间的 I/O 交互,都是通过 IRP 结构体完成。
IRP 结构体
_IRP
是内核 I/O 子系统的核心数据结构,用于描述一次完整的 I/O 请求状态与控制信息。所有 IRP 派发、过滤、传递、完成逻辑,都是围绕该结构体展开的。
1 | // 0x70 bytes (sizeof) |
I/O 层叠栈
由于 Windows 的设备对象组成了一个 I/O 层叠栈(Stacked Device Stack)的结构,因此 IRP
为了能够在按照 I/O 层叠栈(Stacked Device Stack)的结构回的调对应 IRP 派发函数传参,因此其内部也是一个类似堆栈的结构:
StackCount
:总共有多少个设备对象参与 IRP 派发(即设备栈深度)。通常等于最上层 DeviceObject 的StackSize
。CurrentLocation
:当前 IRP 正处于第几层派发阶段。每调用IoSkipCurrentIrpStackLocation()
或IoCallDriver()
时自动递减。Tail.Overlay.CurrentStackLocation
:指向当前设备对象的IO_STACK_LOCATION
结构,记录当前派发层级的参数、控制信息、IRP 参数(如 I/O 控制码、读写长度等)。
1 | IRP 栈状态: |
很多关于 IRP
结构体的 API 本质上就是在操作这三个字段:
API 函数 | 作用 | 本质操作的字段变化 |
---|---|---|
IoGetCurrentIrpStackLocation() |
获取当前派发栈帧指针 | 返回 CurrentStackLocation |
IoGetNextIrpStackLocation() |
获取下一个栈帧指针(仅指针偏移,不修改位置) | 返回 CurrentStackLocation - 1 |
IoSetNextIrpStackLocation() |
手动推进派发位置(很少用) | CurrentLocation-- ,CurrentStackLocation-- |
IoSkipCurrentIrpStackLocation() |
抵消 IoCallDriver 函数内部的“推进派发位置”的操作,使得下一层设备对象仍然处理当前栈帧 |
CurrentLocation++ ,CurrentStackLocation++ |
参数结构
IO_STACK_LOCATION
中存储了参数信息,由于是所有类型的 IRP 派发函数公用,因此内部有一个联合体记录了每种类型的 IRP 派发函数对应的参数结构。
1 | // 0x24 bytes (sizeof) |
通常我们会使用 IoGetCurrentIrpStackLocation
从 IRP
结构体中拿当前栈帧对应的 IO_STACK_LOCATION
参数。当然也可以通过 IoGetNextIrpStackLocation
获取下一层栈帧对应的 IO_STACK_LOCATION
参数或者手动解析 IRP
结构体拿任意一层的参数。
不过下一层的 IO_STACK_LOCATION
默认是空白的。如果我们作为过滤驱动,需要通过 IoCopyCurrentIrpStackLocationToNext
函数将当前栈帧中的参数拷贝到下一层才能让下一层的设备对象对应的驱动在当前栈帧中拿到参数。
而如果是底层设备栈的第一层(创建 IRP 时)则内核早已填好栈帧了,不会有这个问题。
1 | FORCEINLINE |
另外通过 IoSkipCurrentIrpStackLocation
跳过当前设备对象的话也是同样的效果。
返回结构
对于参数传递,IRP
针对每层的设备对象都有对应的 IO_STACK_LOCATION
,然而对于返回值,所有层最终共用同一个 IoStatus
返回区和缓冲区,只要有一层完成请求,设置好 IoStatus.Status
和 IoStatus.Information
即可。
IoStatus
是 IRP
结构体中的一个成员,该成员类型为 IO_STATUS_BLOCK
,定义如下:
1 | // 0x8 bytes (sizeof) |
其中 Status
最终会变成 Win32 API 的返回值,而 Information
会变成 Win32 API 返回的输出字节数。至于输出的数据的存放位置,这个取决于 I/O 缓冲区机制。
I/O 缓冲模式 | 数据缓冲区位置 | 数据写入哪 |
---|---|---|
Buffered I/O (DO_BUFFERED_IO ) |
Irp->AssociatedIrp.SystemBuffer |
驱动填充 SystemBuffer ,内核在 IoCompleteRequest() 时拷贝回用户缓冲区 |
Direct I/O (DO_DIRECT_IO ) |
Irp->MdlAddress (MDL映射的缓冲区) |
驱动使用 MmGetSystemAddressForMdlSafe() 获得内核虚拟地址,直接写入用户缓冲区映射 |
Neither I/O | Irp->UserBuffer (直接原始用户地址) |
驱动直接操作用户缓冲区(前提是地址合法性自己负责验证) |
I/O 缓冲区机制
在驱动开发中,3 环用户程序与 0 环内核驱动需要频繁交换数据。交换数据的过程中需要内核I/O管理器在两者之间做好地址转换、访问隔离、安全控制,为此 Windows 提供了 3 种 I/O 缓冲区机制。
- Buffered I/O(系统缓冲 I/O)
- Direct I/O(直接 I/O)
- Neither I/O(无缓冲 I/O)
I/O 缓冲区机制设置
在 Windows 内核中,用户态与内核态的数据传递的应用主要有两种场景:
- 普通读写:
IRP_MJ_READ
/IRP_MJ_WRITE
- 设备控制:
IRP_MJ_DEVICE_CONTROL
这两种场景的 I/O 缓冲区机制的设置方法是不同的。
IRP_MJ_READ / IRP_MJ_WRITE 这种类型的 IRP 派发函数主要由设备对象
Flags
决定缓冲区模式。当用户调用
ReadFile()
/WriteFile()
时,内核通过 IRP 派发到IRP_MJ_READ
/IRP_MJ_WRITE
。此时内核用DeviceObject->Flags
中的缓冲模式标志位 决定使用哪种缓冲机制:DO_BUFFERED_IO
:Buffered I/O(系统缓冲 I/O)DO_DIRECT_IO
:Direct I/O(直接 I/O)
注意
DO_BUFFERED_IO
与DO_DIRECT_IO
互斥,驱动在创建设备时只需二选一。- 如果两个标志都未设置,默认当做
Buffered I/O
处理。 IRP_MJ_READ
/IRP_MJ_WRITE
不存在 Neither I/O(无缓冲 I/O)模式。
IRP_MJ_DEVICE_CONTROL 这种类型的 IRP 派发函数主要由 IOCTL 控制码决定缓冲区模式。
WDK 提供的
CTL_CODE
宏用于在驱动开发中定义 IOCTL(Input/Output Control)和 FSCTL(File System Control)请求的控制码。控制码本质上是一个 32 位整数,四个参数共同编码出一个唯一的请求类型,供内核和驱动识别具体的控制命令。1
2
3DeviceType
:设备类型代码,占用高 16 位(位 31-16)。如FILE_DEVICE_UNKNOWN
,FILE_DEVICE_DISK
等。由微软规范分配。Access
:访问权限,占用 2 位(位 15-14),控制调用时用户需具备的访问权限。常见取值:FILE_ANY_ACCESS (0)
:不做权限限制FILE_READ_ACCESS (1)
:需要读权限FILE_WRITE_ACCESS (2)
:需要写权限FILE_READ_ACCESS | FILE_WRITE_ACCESS (3)
:需同时具备读写权限
Function
:功能号,占用 12 位(位 13-2),表示具体的功能编号。- 取值范围:0 ~ 4095
- 其中 0 ~ 2047 为微软保留,2048 ~ 4095 供厂商自定义。
- 通常你自己写驱动时使用 2048 以上的数字定义私有控制码,避免与系统冲突。
Method
:缓冲区传递方式,占用 2 位(位 1-0),指定 I/O 缓冲机制。对应四种传输模式:METHOD_BUFFERED (0)
:Buffered I/O(系统缓冲 I/O)METHOD_IN_DIRECT (1)/METHOD_OUT_DIRECT (2)
:输入走 Buffered I/O(系统缓冲 I/O);输出走 Direct I/O(直接 I/O)。METHOD_NEITHER (3)
:Neither I/O(无缓冲 I/O)
Buffered I/O(系统缓冲 I/O)
Buffered I/O(系统缓冲 I/O)机制指的是系统在内核空间自动分配一个中间缓冲区(SystemBuffer
),用于驱动与用户态之间的数据交互。
- 输入数据: 用户数据从用户态缓冲区拷贝至
Irp->AssociatedIrp.SystemBuffer
。 - 输出数据: 驱动程序在处理完成后将结果放入
SystemBuffer
,完成请求时,内核自动从SystemBuffer
将数据拷贝回用户态缓冲区。
这种方式对驱动开发者来说简单安全,避免了内核直接访问用户空间地址带来的复杂性,但是需要额外申请中间缓冲区和拷贝数据,效率较低,资源消耗较大,适用于小规模数据传输、配置参数交互。
Neither I/O(无缓冲 I/O)
Neither I/O(无缓冲 I/O)机制指的是内核不提供任何缓冲区转换或保护措施,驱动程序直接收到用户空间的原始虚拟地址指针(Irp->UserBuffer
),需要自行探测、校验用户空间指针的合法性,风险较高。
在这种机制下,为了避免蓝屏驱动必须:
- 主动调用
ProbeForRead()
探测读权限;ProbeForWrite()
探测写权限。 - 使用
__try
/__except
机制防止访问非法地址。
1 | // Neither I/O 示例(需主动探测) |
注意
微软官方建议:探测用户地址空间 只能用 ProbeForRead
/ ProbeForWrite
,不要用 MmIsAddressValid()
。因为对于用户缓冲区,MmIsAddressValid()
既不可靠也不安全,在 IRQL >= DISPATCH_LEVEL 时调用还有可能蓝屏。
Direct I/O(直接 I/O)
Buffered I/O(系统缓冲 I/O)方式拷贝数据太慢,尤其是大数据量时(如磁盘、网卡、DMA)。而 Neither I/O(无缓冲 I/O)这种内核态访问用户缓冲区的方式,又必须保证该内存合法且不会被换出物理内存,否则一旦页面失效,访问用户空间会蓝屏。
Direct I/O(直接 I/O)在某种程度上是对 Neither I/O(无缓冲 I/O)的改进。该机制确保内核将用户缓冲区锁定为物理内存页面,并创建一个MDL(Memory Descriptor List),驱动通过 Irp->MdlAddress
获取此MDL,再调用内核函数(如MmGetSystemAddressForMdlSafe()
)将物理内存页映射为驱动可访问的内核虚拟地址。
MmGetSystemAddressForMdlSafe
函数原型如下:
1 | PVOID MmGetSystemAddressForMdlSafe( |
Mdl
:要映射的 MDL 地址(Memory Descriptor List)。通常是IRP->MdlAddress
,代表被 Direct I/O 锁定的用户缓冲区物理页链表。Priority
:映射操作时使用的内存分配优先级,指定内核在资源不足时如何处理:NormalPagePriority
— 普通优先级(标准使用,推荐)。HighPagePriority
— 高优先级,尽量尝试分配映射资源。LowPagePriority
— 低优先级,系统紧张时容易失败。VeryLowPagePriority
— 极低优先级,仅适用于诊断或特殊情况。
示例代码如下:
1 | // Direct I/O 示例 (IRP_MJ_READ) |
这种机制安全且效率高,适用于大量数据传输(磁盘、网络、DMA设备)和性能要求较高的驱动程序。
IRP 派发函数
IRP 派发函数是驱动程序中专门处理各类 IRP 请求的回调函数。当 I/O 管理器收到用户或内核发起的 I/O 请求时,系统会根据 IRP 的 MajorFunction
字段,自动把 IRP 分发到对应的派发函数。
IRP 派发函数类别
所有 IRP 派发函数的入口存放在 PDRIVER_OBJECT
结构体内的 MajorFunction[]
数组中:
1 | typedef struct _DRIVER_OBJECT { |
其中 MajorFunction
是一个大小为 28 的函数指针数组,每个元素对应一个 IRP MajorFunction 编号,这些编号的宏定义如下:
IRP MajorFunction (值) | 内核派发说明 | Native API (Zw/Nt) | Win32 API |
---|---|---|---|
IRP_MJ_CREATE (0x00) |
创建 / 打开设备句柄 | ZwCreateFile() / NtCreateFile() |
CreateFile() |
IRP_MJ_CREATE_NAMED_PIPE (0x01) |
命名管道专用 | ZwCreateNamedPipeFile() |
CreateNamedPipe() |
IRP_MJ_CLOSE (0x02) |
关闭设备句柄 | ZwClose() |
CloseHandle() |
IRP_MJ_READ (0x03) |
读取设备数据 | ZwReadFile() |
ReadFile() |
IRP_MJ_WRITE (0x04) |
写入设备数据 | ZwWriteFile() |
WriteFile() |
IRP_MJ_QUERY_INFORMATION (0x05) |
查询文件/设备信息 | ZwQueryInformationFile() |
GetFileInformationByHandle() |
IRP_MJ_SET_INFORMATION (0x06) |
设置文件/设备信息 | ZwSetInformationFile() |
SetFileInformationByHandle() |
IRP_MJ_QUERY_EA (0x07) |
查询扩展属性 (EA) | ZwQueryEaFile() |
无直接 API |
IRP_MJ_SET_EA (0x08) |
设置扩展属性 (EA) | ZwSetEaFile() |
无直接 API |
IRP_MJ_FLUSH_BUFFERS (0x09) |
刷新缓存区 | ZwFlushBuffersFile() |
FlushFileBuffers() |
IRP_MJ_QUERY_VOLUME_INFORMATION (0x0A) |
查询卷信息 | ZwQueryVolumeInformationFile() |
GetVolumeInformation() |
IRP_MJ_SET_VOLUME_INFORMATION (0x0B) |
设置卷信息 | ZwSetVolumeInformationFile() |
无直接 API |
IRP_MJ_DIRECTORY_CONTROL (0x0C) |
目录操作 | ZwQueryDirectoryFile() |
FindFirstFile() / FindNextFile() |
IRP_MJ_FILE_SYSTEM_CONTROL (0x0D) |
文件系统控制 | ZwFsControlFile() |
无直接 API |
IRP_MJ_DEVICE_CONTROL (0x0E) |
设备控制(IOCTL) | ZwDeviceIoControlFile() |
DeviceIoControl() |
IRP_MJ_INTERNAL_DEVICE_CONTROL (0x0F) |
内部设备控制 | 内核内部 | 无 |
IRP_MJ_SHUTDOWN (0x10) |
关机通知 | ZwShutdownSystem() |
无 |
IRP_MJ_LOCK_CONTROL (0x11) |
锁控制 | ZwLockFile() / ZwUnlockFile() |
LockFile() / UnlockFile() |
IRP_MJ_CLEANUP (0x12) |
句柄清理 (Close 前触发) | 自动派发 | CloseHandle() (间接) |
IRP_MJ_CREATE_MAILSLOT (0x13) |
创建邮件槽 | ZwCreateMailslotFile() |
CreateMailslot() |
IRP_MJ_QUERY_SECURITY (0x14) |
查询安全信息 | ZwQuerySecurityObject() |
GetSecurityInfo() |
IRP_MJ_SET_SECURITY (0x15) |
设置安全信息 | ZwSetSecurityObject() |
SetSecurityInfo() |
IRP_MJ_POWER (0x16) |
电源管理 | 内核电源管理 | 无 |
IRP_MJ_SYSTEM_CONTROL (0x17) |
WMI控制 | WMI子系统派发 | WMI系列API |
IRP_MJ_DEVICE_CHANGE (0x18) |
设备插拔通知 | 自动派发 | RegisterDeviceNotification() (部分场景) |
IRP_MJ_QUERY_QUOTA (0x19) |
查询磁盘配额 | ZwQueryQuotaInformationFile() |
无 |
IRP_MJ_SET_QUOTA (0x1A) |
设置磁盘配额 | ZwSetQuotaInformationFile() |
无 |
IRP_MJ_PNP (0x1B) |
即插即用 | PnP子系统派发 | 设备管理器控制 |
IRP_MJ_PNP_POWER (0x1B) |
历史兼容(已废弃别名) | —— | —— |
IRP_MJ_MAXIMUM_FUNCTION (0x1B) |
内核保留 | —— | —— |
IRP 派发函数注册
每个 IRP 派发函数都有统一的标准函数签名:
1 | NTSTATUS DispatchFunction( |
我们需要在 DriverEntry()
中定义该类型的函数,并将其注册到 MajorFunction[]
数组中。
1 | DriverObject->MajorFunction[IRP_MJ_CREATE] = DispatchCreate; |
注意
最少也要注册 IRP_MJ_CREATE
/ IRP_MJ_CLOSE
/ IRP_MJ_DEVICE_CONTROL
,否则基本无法和用户层通信。
IRP_MJ_CREATE
和IRP_MJ_DEVICE_CONTROL
保证驱动能和用户交互,IRP_MJ_CLOSE
保证驱动能安全退出。
IRP 派发函数实现
IRP 派发函数基本都是如下过程:取参数 → 业务逻辑处理 → 设置返回值 → 完成请求
以 IRP_MJ_DEVICE_CONTROL
为例,常见的实现如下:
1 | NTSTATUS MyDispatchDeviceControl(PDEVICE_OBJECT DeviceObject, PIRP Irp) |
其中关于最后的完成机制:
Irp->IoStatus.Status
、Irp->IoStatus.Information
是IRP 完成结果IoStatus.Status
—— 你要告诉 I/O 管理器,这次 IRP 处理的结果:- 成功:
STATUS_SUCCESS
- 失败:其他各种 NTSTATUS 错误码
- 成功:
IoStatus.Information
—— 返回给用户的附加数据长度(如:读取了多少字节、写入了多少字节、DeviceIoControl 输出了多少数据)
提示
这两个值并不会自动产生,而是你驱动在派发函数中自己填写进去的。否则内核无法知道你的处理结果。
IoCompleteRequest()
的作用 —— 提交完成、唤醒上层1
IoCompleteRequest(Irp, IO_NO_INCREMENT);
这个 API 的职责是通知内核:这个 IRP 完成了,内核会:
- 清理 IRP
- 唤醒等待该 IRP 完成的用户线程(如果是同步 I/O)
- 让用户态的
ReadFile()
、WriteFile()
、DeviceIoControl()
等 API 得到返回 - 把刚才填的
IoStatus.Status
、Information
传回用户态 API
注意
没有调用 IoCompleteRequest(),IRP 永远不会完成,用户线程会一直挂死等待。
return status
是内核派发器IofCallDriver()
内部会接收你的派发函数返回值(也就是return status
)。但注意:最终送到用户态的结果,并不是这个返回值,而是Irp->IoStatus.Status
。
IRP 派发过程
IRP 派发(IRP Dispatch)是 Windows 内核向驱动程序发送 I/O 请求的核心机制。每个驱动通过注册的派发函数处理特定类型的 IRP(如 READ
、WRITE
、DEVICE_CONTROL
等),整个请求沿 I/O 层叠栈(Stacked Device Stack)自顶向下传递。
提示
物理设备附加链(AttachedDevice 链)本身不会直接参与 IRP 派发逻辑,但它确实影响了 IRP 堆栈的分配深度(StackCount)。
IRP 派发是通过 IoCallDriver
函数实现的,该函数原型如下:
1 | NTSTATUS IoCallDriver( |
DeviceObject
:要将 IRP 派发给的目标设备对象(一般是下层的 Filter 或 Functional Device)。其对应的驱动由DeviceObject->DriverObject
决定。Irp
:要派发的 I/O 请求数据包。调用本函数前,必须设置好 IRP 的栈帧位置(通常使用IoSkipCurrentIrpStackLocation()
或手动设置CurrentLocation--
)。
IoCallDriver
将一个 IRP 派发到指定的设备对象所对应驱动的派发函数中。这是驱动中用于将 I/O 请求“传递”给下层驱动的标准方式。
1 |
|
从 IoCallDriver
函数的实现来看,该函数会:
- 推进 IRP 栈帧
CurrentLocation--
,Tail.Overlay.CurrentStackLocation--
。 - 调用下层驱动的的 IRP 派发函数
DeviceObject->DriverObject->MajorFunction[Irp->Tail.Overlay.CurrentStackLocation->MajorFunction]
。
而我们知道对于 IRP:
- 如果是底层设备栈的第一层(创建 IRP 时)则内核早已填好栈帧了。
- 而下一层的
Tail.Overlay.CurrentStackLocation
默认是空白的。
因此我们需要调用 IoCopyCurrentIrpStackLocationToNext
将当前 IRP 栈帧中的数据拷贝到下一层。这样才能确保过滤驱动不会影响到下层驱动的传参。
然而调用 IoCallDriver
函数会导致 IRP 堆栈减少一层,而如果我们没有事先做设备附加操作的话,可能会导致 IRP 堆栈层数不够造成蓝屏:
1 | // |
我们可以通过 IoSetNextIrpStackLocation
将 CurrentLocation++
,Tail.Overlay.CurrentStackLocation++
来抵消 IoCallDriver
函数的影响。因此更通用的写法为:
1 | NTSTATUS MyDispatchIoctl(PDEVICE_OBJECT DeviceObject, PIRP Irp) |
- Title: windows 驱动基础
- Author: sky123
- Created at : 2022-09-28 11:45:14
- Updated at : 2025-07-05 01:11:49
- Link: https://skyi23.github.io/2022/09/28/windows 驱动基础/
- License: This work is licensed under CC BY-NC-SA 4.0.